纯 ESM 包:现代 JavaScript 模块化指南
- 原文链接: https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c
 - 机器翻译: Gemini 2.5 Pro Preview
 - 提示词: 翻译以下文档,并且结合你的前端开发专业知识补充相关的知识点说明,对文章内容进行结构化,以 Markdown 格式返回,文字风格是技术博客。
 - 翻译理由:越来越多的库开发者讲自己的包直接打包成纯 ESM 形式,并且指向这个文档,让库使用者也尽快迁移到 ESM。
 
核心问题:无法再使用 require()
关键点:一个 Pure ESM 包不能再通过 CommonJS 的 require() 函数来同步导入。
// ❌ 这种方式在 CommonJS 项目中会报错
// const foo = require('pure-esm-package');
// ✅ 需要改用 ESM 的 import 语法
// import foo from 'pure-esm-package';
这种转变是 JavaScript 生态系统逐步拥抱官方标准模块系统的一部分。虽然带来了阵痛,但长远来看,它统一了前后端的模块规范,带来了诸多好处。
如何应对 Pure ESM 包?
面对这种情况,你有以下几种选择:
- 
迁移你的项目到 ESM (强烈推荐):
- 方法:在你的代码中使用 
import foo from 'pure-esm-package'替代const foo = require('pure-esm-package')。 - 配套措施:需要在 
package.json中添加"type": "module"声明,并可能需要调整构建配置、文件扩展名等。下文会详细介绍。 - 前端视角:现代前端框架(如 Vue 3, React with Vite, SvelteKit, Next.js 12+)原生或更好地支持 ESM。迁移到 ESM 可以更好地利用 Tree Shaking(摇树优化)减少最终打包体积,并与浏览器原生模块加载机制保持一致。
 
 - 方法:在你的代码中使用 
 - 
在 CommonJS 中使用动态
import()(异步上下文):- 方法:如果你的代码允许异步操作,可以使用 
await import('pure-esm-package')来加载 ESM 包。 - 示例:
// 在 async 函数或顶层 await (Node.js >= 14.8) 中
async function loadMyModule() {
const { default: foo } = await import("pure-esm-package");
// 注意:ESM 默认导出的模块需要通过 .default 访问
// 如果是命名导出,则: const { namedExport } = await import('pure-esm-package');
foo.doSomething();
} - 限制:这只能在异步函数或支持顶层 
await的环境中使用。无法用于需要同步返回模块的场景。 - 前端视角:动态 
import()在前端常用于代码分割(Code Splitting),按需加载路由或组件,以优化首屏加载性能。这里的用法类似,但目的是解决 CJS/ESM 互操作性问题。 
 - 方法:如果你的代码允许异步操作,可以使用 
 - 
锁定旧版本:
- 方法:暂时停留在该 Pure ESM 包的最后一个 CommonJS 兼容版本。
 - 风险:你将无法获得该包后续的新功能、性能优化和安全修复。这通常只是一个短期缓兵之计。
 
 - 
尝试 Node.js 22+ 的
require()ESM 支持 (不推荐):- 背景:Node.js 22 开始实验性地支持通过 
require()加载 同步 ESM 模块图。 - 强烈建议:官方和社区普遍不推荐依赖此特性。它可能存在性能问题、兼容性陷阱,且违背了 ESM 的设计哲学。迁移到 ESM 才是正途。
 
 - 背景:Node.js 22 开始实验性地支持通过 
 
最低环境要求:请确保你的 Node.js 版本至少为 v16,强烈推荐使用 Node.js 18 或更高版本,因为许多现代工具和库都已将此作为最低要求,并且对 ESM 的支持更为完善。
核心理念:ESM 可以导入 CommonJS 包(通常没有问题),但 CommonJS 包无法同步 require() ESM 包。这是单向的兼容性。
请注意:作者明确表示,其仓库不是解答通用 ESM、TypeScript、Webpack、Jest、ts-node、Create React App (CRA) 等工具支持问题的场所。请查阅相应工具的官方文档或社区。
FAQ:迁移与实践详解
Q1: 如何将我的 CommonJS 项目迁移到 ESM?
以下是关键步骤:
- 
package.json设置:- 添加 
"type": "module"。这告诉 Node.js (和许多构建工具) 默认将.js文件视为 ESM。 - 将 
"main": "index.js"替换为"exports": "./index.js"。"exports"字段提供了更精细的包入口点控制,是现代 Node.js 包的标准。 - 更新 
"engines"字段,建议至少"node": ">=18"。 
 - 添加 
 - 
代码调整:
- 移除所有文件顶部的 
'use strict';(ESM 默认就是严格模式)。 - 将所有 
require()和module.exports/exports.*替换为import和export语法。 - 使用完整的相对文件路径:导入本地文件时必须包含文件扩展名(通常是 
.js,即使源文件是.ts编译后也应导入.js)。例如:import x from './utils';需改为import x from './utils.js';。 - 导入 Node.js 内置模块时,使用 
node:协议前缀,例如:import fs from 'node:fs';。这能明确区分内置模块和第三方模块,并有助于某些场景下的解析。 
 - 移除所有文件顶部的 
 - 
TypeScript 类型定义 (如果适用):
- 如果你的包包含类型定义文件 (如 
index.d.ts),确保其中的导入/导出语法也更新为 ESM 格式。 
 - 如果你的包包含类型定义文件 (如 
 
扩展阅读:如果你对如何为 JavaScript 包添加类型定义感兴趣,可以参考作者的 TypeScript Definition Style Guide。
Q2: 如何在 TypeScript 项目中使用 (或输出) ESM?
是的,完全可以!你需要配置 TypeScript 项目以输出 ESM 格式的代码。
关键步骤:
- TypeScript 版本:确保使用 TypeScript 4.7 或更高版本。
 package.json配置:- 添加 
"type": "module"。 - 将 
"main"替换为"exports"(同上)。 - 更新 
"engines"至 Node.js 18+ (同上)。 
- 添加 
 tsconfig.json配置:- 设置 
"module": "node16"或"module": "nodenext"。非常重要,这指示 TypeScript 生成与 Node.js ESM 兼容的 JavaScript 代码。 - 设置 
"moduleResolution": "node16"或"moduleResolution": "nodenext"。同样重要,这告诉 TypeScript 编译器如何查找模块,使其行为与 Node.js 的 ESM 解析规则一致。绝对不能设为"node"! - 示例配置参考:sindresorhus/tsconfig
 
- 设置 
 - 代码调整:
- 必须使用 
.js扩展名进行相对导入,即使你实际导入的是.ts文件。TypeScript 会在编译时正确处理它们。import util from './util';->import util from './util.js'; - 移除 
namespace用法,改用export。 - 使用 
node:协议导入 Node.js 内置模块。 
 - 必须使用 
 
关于 ts-node:如果使用 ts-node 直接运行 TS 代码,需要遵循 ts-node ESM 指南。参考配置:Example config。(推荐使用 tsx 作为替代品)
Q3: Electron 中如何使用 ESM?
Electron 从版本 28 开始支持 ESM。请参考 Electron ESM 官方文档。
Q4: Webpack 构建遇到 ESM 问题怎么办?
问题很可能出在 Webpack 本身或你的 Webpack 配置上。
- 确保你使用的是最新版本的 Webpack。
 - 检查你的 
webpack.config.js是否正确处理了.js/.mjs文件以及package.json中的"type": "module"。可能需要调整resolve、module.rules等配置。 - 不要 在原作者的仓库提问。尝试在 Stack Overflow 提问或在 Webpack 的 GitHub 仓库 提交 issue。
 
前端补充:Webpack 5 对 ESM 有了更好的原生支持,但与 CJS 混用、依赖项的模块格式、以及 loader/plugin 的兼容性仍可能引发问题。确保 experiments.outputModule (如果需要输出 ESM bundle) 或 module.rules 中对 .mjs 和 .js (在 "type": "module" 项目中) 的处理是正确的。Vite 等基于原生 ESM 的构建工具通常能更顺畅地处理 ESM 依赖。
Q5: Next.js 构建遇到 ESM 问题怎么办?
升级到 Next.js 12 或更高版本。Next.js 12 引入了对 ESM 的全面支持,包括 npm 包和 URL Imports。
Q6: Jest 测试遇到 ESM 问题怎么办?
Jest 对 ESM 的支持仍在发展中。
- 阅读 Jest 官方 ESM 文档。
 - 你可能需要:
- 在 Node.js 运行时添加 
--experimental-vm-modules标志 (通过NODE_OPTIONS环境变量)。 - 使用 
jest-environment-node或自定义环境。 - 配置 
transform选项来处理 ESM 语法(如果需要转译)或设置为空对象{}以禁用 CommonJS 转换。 - 确保你的 
package.json设置了"type": "module"。 
 - 在 Node.js 运行时添加 
 
前端补充:Vitest 是一个基于 Vite 的现代测试框架,它原生支持 ESM,并且配置通常比 Jest 更简单,可以作为 Jest 的替代方案。
Q7: TypeScript + ESM 遇到的其他问题?
再次确认:
package.json中有"type": "module"。tsconfig.json中设置了"module": "node16"(或nodenext)。- 所有本地文件的导入语句都使用了 
.js扩展名。 
Q8: ts-node + ESM 遇到的问题?
推荐替代品:考虑使用 tsx,它提供了更好的 ESM 支持和性能。
如果仍要使用 ts-node,请确保是最新版本,并遵循 这个指南。示例配置。
Q9: Create React App (CRA) 遇到 ESM 问题怎么办?
CRA 对 Pure ESM 包的支持还不完善。已知问题如 #10933。
- 建议:向 CRA 仓库报告你遇到的具体问题。
 - 前端补充:CRA 的底层构建工具 (Webpack) 和配置可能没有完全跟上 ESM 的步伐。对于新项目或需要更好 ESM 支持的项目,可以考虑使用 Vite + React 模板,Vite 对 ESM 的原生支持使其处理这类依赖更加顺畅。
 
Q10: 如何在 ESM 项目中使用 TypeScript 和 AVA 进行测试?
遵循 AVA 官方 TypeScript 指南 (针对 type: module 包)。
Q11: 如何确保不意外使用 CommonJS 特有的写法?
使用 ESLint 规则来强制执行 ESM 最佳实践:
eslint-plugin-unicorn/prefer-module: 强制使用 ESM 风格(import/export,.js扩展名等)。eslint-plugin-unicorn/prefer-node-protocol: 强制使用node:协议导入内置模块。
Q12: ESM 中没有 __dirname 和 __filename,怎么办?
使用 import.meta.url 来获取当前模块的 URL。
- 
Node.js 20.11+ / 21.2+:
- 可以直接使用 
import.meta.dirname和import.meta.filename。 
 - 可以直接使用 
 - 
旧版 Node.js:
import { fileURLToPath } from "node:url";
import path from "node:path";
// 获取当前文件的绝对路径
const __filename = fileURLToPath(import.meta.url);
// 获取当前文件所在目录的绝对路径
const __dirname = path.dirname(__filename); // 或者 path.dirname(fileURLToPath(import.meta.url)) - 
更常用的模式 (构造相对于当前模块的路径):
import { fileURLToPath } from "node:url";
// 获取同目录下 foo.js 的文件系统路径
const fooPath = fileURLToPath(new URL("foo.js", import.meta.url)); - 
许多 Node.js API 直接接受 URL 对象:
// 直接创建 URL 对象,可能可以直接传递给某些 API (如 fs.readFile)
const fooUrl = new URL("foo.js", import.meta.url);
// await fs.readFile(fooUrl); // 示例 
前端补充:在浏览器环境中,import.meta.url 同样可用,它返回的是模块的 URL。但在前端构建打包后,这个值可能指向 blob: URL 或打包后的文件路径,其行为可能与 Node.js 环境不完全一致。通常在前端代码中直接操作文件系统的场景较少,更多是处理相对资源的 URL。
Q13: 测试时如何导入模块并绕过缓存?
在 ESM 中,没有像 CommonJS delete require.cache[modulePath] 那样标准的、简单的方法来清除缓存。
- 
临时方案 (仅限测试,有内存泄漏风险!): 通过给导入路径添加动态查询参数来强制重新加载。
const importFresh = async (modulePath) => {
// 添加时间戳作为查询参数,欺骗模块加载器认为是不同的模块
return import(`${modulePath}?t=${Date.now()}`);
};
// 使用示例
const chalk = (await importFresh("chalk")).default;警告:
- 这种方法会导致内存泄漏,因为旧模块实例不会被垃圾回收。绝对不要在生产环境中使用。
 - 它只会重新加载你直接导入的那个模块,不会重新加载其依赖项。
 
 - 
未来:Node.js 的 ESM Loader Hooks 成熟后可能会提供更好的解决方案。
 
前端补充:在浏览器端,模块缓存由浏览器管理。测试框架(如 Vitest)通常有自己的模块模拟(mocking)和隔离机制,不依赖这种 hacky 的缓存清除方式。
Q14: 如何导入 JSON 文件?
- 
Node.js 17.5+ (需要
--experimental-json-modules标志) / Node.js 18.20+ (稳定):- 使用 Import Assertions (导入断言):
import packageJson from './package.json' with { type: 'json' };
console.log(packageJson.version); 
 - 使用 Import Assertions (导入断言):
 - 
旧版 Node.js 或不使用实验性特性: * 使用
fs模块读取文件并手动解析:import fs from 'node:fs/promises';
const packageJsonContent = await fs.readFile('./package.json', 'utf8');
const packageJson = JSON.parse(packageJsonContent);
console.log(packageJson.version);
```
**前端补充**:现代前端构建工具(Webpack, Vite, Rollup 等)通常内置了对 JSON 导入的支持,你可以在代码中直接 `import data from './data.json';`,构建工具会负责处理。Import Assertions 是更标准化的方式,未来可能会被构建工具更广泛地原生支持。 
Q15: 何时使用默认导出 (default export) vs 命名导出 (named exports)?
这是一个风格和实践问题,作者给出了他的建议:
- 
默认导出 (
export default):- 
适用于一个模块主要导出一个核心功能、类或对象时。
 - 
例如:一个
left-pad库主要就是那个填充函数。 - 
可以与命名导出结合使用:
// read-json.js
export default function readJson() {
/* ... */
}
export class JSONError extends Error {
/* ... */
}
// usage.js
import readJson, { JSONError } from "read-json"; 
 - 
 - 
命名导出 (
export { ... }或export const/function/class ...):- 
多个主要 API: 如果一个包提供多个同等重要的功能,特别是包含同步和异步版本时,使用命名导出更清晰。
// read-json.js
export function readJson() {
/* async */
}
export function readJsonSync() {
/* sync */
}
// usage.js
import { readJson, readJsonSync } from "read-json"; // 清晰区分 - 
避免模糊命名: 避免使用过于通用的名称作为命名导出,这会迫使消费者重命名以防冲突。
// ❌ 不好的例子: parse-json.js
// export function parse() { ... }
// 消费者需要重命名
// import { parse as parseJson } from 'parse-json';
// ✅ 好的例子: parse-json.js
export function parseJson() { ... }
// 消费者直接使用
import { parseJson } from 'parse-json'; - 
取代命名空间模式: ESM 中,倾向于使用描述性的命名导出,而不是像 CommonJS 那样导出一个包含多个方法的对象(命名空间)。
// CommonJS (旧)
// const isStream = require('is-stream');
// isStream.writable(stream);
// ESM (推荐)
// is-stream.js
// export function isStream() { ... }
// export function isReadableStream() { ... }
// export function isWritableStream() { ... }
// usage.js
import { isWritableStream } from "is-stream";
isWritableStream(stream); 
 - 
 
前端补充:
- Tree Shaking: 命名导出通常对 Tree Shaking 更友好。构建工具能更容易地静态分析出哪些命名导出被使用了,从而移除未使用的代码。默认导出如果是对象或类,其内部方法的摇树优化可能需要更复杂的分析或特定写法。
 - 组件库: 大型组件库通常使用命名导出来导出各个组件,方便用户按需导入:
import { Button, Modal } from 'my-ui-library';。 - 可读性与可发现性: 命名导出使得模块提供的功能更加一目了然,IDE 也能提供更好的自动补全提示。
 
总结
转向 Pure ESM 是 JavaScript 生态系统发展的必然趋势。虽然短期内会给使用 CommonJS 的项目带来一些挑战,但理解 ESM 的基本原理、掌握迁移步骤和解决常见问题的策略至关重要。积极拥抱 ESM 不仅能让你使用最新的库和工具,还能享受到模块标准化、性能优化(如更好的 Tree Shaking)和与浏览器环境更一致的开发体验。对于前端开发者而言,熟悉 ESM 更是现代 Web 开发的基础。